/**
 * Copyright 2009 Jesse Brown (jbrown@fluentv.com)
 * 
 * Licensed under the Apache License, Version 2.0 (the "License"); 
 * you may not use this file except in compliance with the License. 
 * You may obtain a copy of the License at 
 *   http://www.apache.org/licenses/LICENSE-2.0 
 * 
 * Unless required by applicable law or agreed to in writing, software 
 * distributed under the License is distributed on an "AS IS" BASIS, 
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 
 * See the License for the specific language governing permissions and 
 * limitations under the License. 
 */
package com.fluentv.games.netblocks;

import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;

import android.content.Context;
import android.content.SharedPreferences;
import android.content.SharedPreferences.Editor;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Paint.Style;
import android.util.Log;

import com.fluentv.games.netblocks.piece.Shape;

/**
 * Provides the block "playing field" that tracks blocks. 
 * Is Serializable to handle saving/dumping state.
 * 
 * @author Jesse Brown (jbrown@fluentv.com)
 */
public class PlayingField extends DrawableTetrisField {  

  private static final String LOG_TAG = "PlayingField";
  private static final String FIELD_KEY = "Field";
  private static final String HEIGHT_CELLS_KEY = "HeightCells";
  private static final String WIDTH_CELLS_KEY = "WidthCells";
  private static final String CURRENT_SHAPE_X_KEY = "CurrentShapeX";
  private static final String CURRENT_SHAPE_Y_KEY = "CurrentShapeY";
  private static final String CURRENT_SHAPE_KEY = "CurrentShape";
   
  // Timers to track last elapsed times for certain actions. 
  private long lastDropCurrentShape = -1;
  
  /*
   * Two dimensional playing field. Each x/y is a reference to the Shape
   * currently occupying each square of the field. 0,0 is top left of the field.
   * The first dimension is heightCells, the second dimension is widthCells.
   * 
   * Empty heightCells rows are null to allow faster empty row detection.
   */
  private Shape[][] field;
  private int heightCells;
  private int widthCells;

  // Current shape X, and Y cell locations (for moving shapes)
  private int currentShapeX;
  private int currentShapeY;
  private Shape currentShape;
  
  /**
   * Create a new playing field with the specified widthCells and heightCells
   */
  public PlayingField(Context context, int widthCells, int heightCells) {
    super(context);

    if (widthCells <= 0 || heightCells <= 0) {
      throw new IllegalArgumentException(
          "widthCells and heightCells must be > 0");
    }

    this.heightCells = heightCells;
    this.widthCells = widthCells;
  }

  /**
   * Reset the field using the current heightCells and widthCells
   */
  synchronized public void reset() {
    currentShapeX = 0;
    currentShapeY = 0;
    currentShape = null;
    lastDropCurrentShape = -1;
    field = new Shape[heightCells][];
  }

  @Override
  protected synchronized Shape getCell(int cellX, int cellY) {
    Shape[] row = null;
    try {
      row = field[cellY - 1];
    } catch (ArrayIndexOutOfBoundsException ex) {
      throw new IllegalArgumentException("cellY " + cellY + " is out of bounds");
    }

    if (row == null) {
      return null;
    }

    Shape cellValue = null;
    try {
      cellValue = row[cellX - 1];
    } catch (ArrayIndexOutOfBoundsException ex) {
      throw new IllegalArgumentException("cellX " + cellX + " is out of bounds");
    }

    return cellValue;
  }

  /**
   * Set a Shape at the X (widthCells) and Y (heightCells) co-ordinates
   * 
   * @param shape
   *          Shape object (or null) to set
   * @param cellX
   *          1 to widthCells cell address
   * @param cellY
   *          1 to heightCells cell address.
   * @throws IllegalArgumentException
   *           If the cell coordinates are out of range
   */
  synchronized void setCell(Shape shape, int cellX, int cellY) {
    Shape[] row = null;
    try {
      row = field[cellY - 1];
      if (row == null) {
        row = new Shape[widthCells];
        field[cellY - 1] = row;
      }
    } catch (ArrayIndexOutOfBoundsException ex) {
      throw new IllegalArgumentException("cellY " + cellY + " is out of bounds");
    }

    try {
      row[cellX - 1] = shape;
    } catch (ArrayIndexOutOfBoundsException ex) {
      throw new IllegalArgumentException("cellX " + cellX + " is out of bounds");
    }
  }

  /**
   * Return an entire cell row
   */
  synchronized Shape[] getCellRow(int cellY) {
    try {
      return field[cellY - 1];
    } catch (ArrayIndexOutOfBoundsException ex) {
      throw new IllegalArgumentException("cellY " + cellY + " is out of bounds");
    }
  }

  /**
   * Add a shape to the field. If the shape cannot be added (no room), returns
   * false.
   * 
   * @param newShape
   *          new shape to add to the field
   * @return true if the shape can be successfully added, false if not
   * @throws IllegalArgumentException
   *           if shape is not at least 1x1 blocks
   */
  synchronized public boolean addShape(Shape newShape) {
    int leftCellX = offsetToCenterShape(newShape);
    int topCellY = getTopY();

    // See if we will clip with another shape if inserted with the mask. If so,
    // return false.
    if (!noClip(newShape, leftCellX, topCellY)) {
      return false;
    }

    addShape(newShape, leftCellX, topCellY);
    return true;
  }
  
  /**
   * Internal method to add a shape with given co-ordinates. Does NOT perform clipping.
   */
  synchronized private void addShape(Shape newShape, int leftCellX, int topCellY) {
    // Add with the mask to the field.
    newShape.processMask(new AddShapeMaskProcessor(newShape, leftCellX,
        topCellY));

    currentShape = newShape;
    currentShapeX = leftCellX;
    currentShapeY = topCellY;

    postInvalidate();    
  }

  /**
   * Move a shape in the field by one in the chosen direction. If there is no
   * current shape, returns false.
   * 
   * @param direction
   *          MoveDirection specifying the direction to move the current shape.
   * @return true if the shape could be successfully moved, false if not.
   */
  synchronized public boolean moveCurrentShape(MoveDirection direction) {
    if (currentShape == null) {
      return false;
    }

    int newX = currentShapeX;
    int newY = currentShapeY;

    if (direction == MoveDirection.LEFT) {
      // X - 1, Y unchanged.
      newX--;
    } else if (direction == MoveDirection.RIGHT) {
      // X + 1, Y unchanged.
      newX++;
    } else if (direction == MoveDirection.DOWN) {
      // X unchanged, Y++
      newY++;
    } else {
      throw new IllegalArgumentException("Unknown MoveDirection value");
    }

    // See if we clip at the new location.
    if (!noClip(currentShape, newX, newY)) {
      // Oops - we do.
      
      return false;
    }

    // Okay, remove our shape using the mask and the old location.
    deleteShape(currentShape);

    // Add our shape using the mask and the new location.
    currentShape.processMask(
        new AddShapeMaskProcessor(currentShape, newX, newY));

    // Update our current location.
    currentShapeX = newX;
    currentShapeY = newY;

    postInvalidate();
    return true;
  }

  /**
   * Delete the shape from the field
   */
  public synchronized void deleteShape(Shape shape) {
    for (int y = 1; y <= getCellsHigh(); y++) {
      for (int x = 1; x <= getCellsWide(); x++) {
        if (getCell(x, y) == shape) {
          setCell(null, x, y);
        }
      }
    }
  }

  /**
   * Find if a shape, placed with the upper left of its mask on the specified X
   * and Y offsets of the field, will collide with another shape currently on
   * the field.
   * 
   * @param shape
   *          detect clipping for this shpe
   * @param xAddress
   *          widthCells offset in the field to start detection at
   * @param yAddress
   *          heightCells offset in the field to start detection at
   * @param exclusions
   *          Shapes (besides the current shape) to exclude from clipping
   *          calculations
   * @return true if it will not clip, false otherwise.
   */
  synchronized public boolean noClip(Shape shape, int xAddress, int yAddress,
      Shape... exclusions) {
    
    // False if a mask cell conflicts
    return shape.processMask(new ClippingMaskProcessor(shape, xAddress,
        yAddress, exclusions));
  }

  /**
   * Find the offset to center the shape in the widthCells of the current field.
   * 
   * @param shape
   *          shape to find offset for
   * @return new offset (always fits in the current field)
   */
  int offsetToCenterShape(Shape shape) {
    // Attempt to center for widthCells.
    int offset = getCenterX() - (shape.getWidth() / 2);
    if (offset >= 1) {
      return offset;
    } else {
      return 1;
    }
  }

  /**
   * Find and return the offset of the center top.
   * 
   * @return center (widthwise) element
   */
  int getCenterX() {
    return widthCells / 2;
  }

  /**
   * Return the cell Y address of the top of the field (1)
   * 
   * @return top (heightwise) address
   */
  int getTopY() {
    return 1;
  }

  /**
   * Field heightCells in cells
   */
  @Override
  protected int getCellsHigh() {
    return heightCells;
  }

  /**
   * Field widthCells in cells
   */
  @Override
  protected int getCellsWide() {
    return widthCells;
  }

  /**
   * Freeze the current shape (make it no longer the current shape). This is in prep for finding solved rows.
   */
  public synchronized void freezeCurrentShape() {
    currentShape = null; 
  }
  
  /**
   * Find, count, and remove all solved rows (all rows where all cells across
   * are filled). 
   * 
   * @return number of solved rows found and removed
   */
  public synchronized int findSolvedRows() {
    int solved = 0;

    // Construct a new field to be populated with unsolved rows.
    Shape[][] newField = new Shape[getCellsHigh()][getCellsWide()];
    int newY = getCellsHigh() - 1;

    // Count down, ignoring all solved rows
    for (int y = getCellsHigh(); y >= 1; y--) {
      Shape[] cellRow = getCellRow(y);
      boolean isRowSolved = true;

      if (cellRow != null) {
        for (Shape cellValue : cellRow) {
          if (cellValue == null) {
            isRowSolved = false;
            break;
          } else if (cellValue == currentShape) {
            isRowSolved = false;
            break;
          }
        }
      } else {
        isRowSolved = false;
      }

      if (!isRowSolved) {
        newField[newY] = cellRow;
        newY--;
      } else {
        solved++;
      }
    }

    if (solved > 0) {
      field = newField;
      postInvalidate();
    }

    return solved;
  }

  /**
   * Available move directions.
   */
  enum MoveDirection {
    LEFT, RIGHT, DOWN
  }

  /**
   * Return the cell Y of the bottom of the field
   * 
   * @return bottom (heightwise) address
   */
  int getBottomY() {
    return heightCells;
  }

  /**
   * Rotates right 90 degrees.
   */
  public synchronized boolean rotateCurrentShape() {
    if (currentShape == null) { 
      return false;
    }

    // Rotate and generate our new shape.
    Shape rotated = currentShape.rotate();

    // If it would clip, abort.
    if (!noClip(rotated, currentShapeX, currentShapeY, currentShape)) {
      return false;
    }

    // Okay, remove our shape using the mask and the old location.
    deleteShape(currentShape);

    // Add our shape using the mask and the new location.
    currentShape = rotated;
    rotated.processMask(new AddShapeMaskProcessor(currentShape,
        currentShapeX, currentShapeY));

    invalidate();
    return true;
  }

  /**
   * See if any mask bits would collide with another shape in the field, or the
   * borders of the field.
   * 
   * @author jessebro
   */
  private class ClippingMaskProcessor implements Shape.MaskCellProcessor {
    private int startFieldX;
    private int startFieldY;
    private Set<Shape> exclude = new HashSet<Shape>();

    ClippingMaskProcessor(Shape shape, int leftFieldX, int topFieldY,
        Shape... exclusions) {
      startFieldX = leftFieldX;
      startFieldY = topFieldY;
      exclude.add(shape);
      exclude.addAll(Arrays.asList(exclusions));
    }

    @Override
    public boolean processMaskCell(boolean cellValue, int maskX, int maskY) {
      if (!cellValue) {
        return true;
      }

      int fieldCellY = startFieldY + maskY;
      int fieldCellX = startFieldX + maskX;

      // True if the cell is NULL (nothing there) or our current shape,
      // false otherwise.
      try {
        Shape cellShape = PlayingField.this.getCell(fieldCellX, fieldCellY);
        if (cellShape == null || exclude.contains(cellShape)) {
          return true;
        } else {
          return false;
        }
      } catch (IllegalArgumentException ex) {
        // Out of bounds - won't fit where they wanted to put it.
        return false;
      }
    }
  }

  /**
   * Add our shape to all cells marked in the mask.
   * 
   * @author jessebro
   */
  private class AddShapeMaskProcessor implements Shape.MaskCellProcessor {
    private Shape shape;
    private int startFieldX;
    private int startFieldY;

    AddShapeMaskProcessor(Shape newShape, int leftFieldX, int topFieldY) {
      shape = newShape;
      startFieldX = leftFieldX;
      startFieldY = topFieldY;
    }

    @Override
    public boolean processMaskCell(boolean cellValue, int maskX, int maskY) {
      if (!cellValue) {
        return true;
      }

      int fieldCellY = startFieldY + maskY;
      int fieldCellX = startFieldX + maskX;

      // True if the cell is NULL (nothing there), false otherwise.
      PlayingField.this.setCell(shape, fieldCellX, fieldCellY);
      return true;
    }
  }

  /**
   * 'Drop' the piece by moving it down until it can't move anymore (due to
   * clipping).
   */
  public synchronized void dropCurrentShape() {
    while (moveCurrentShape(MoveDirection.DOWN)) {
      // Successful move, keep looping.
    }
    lastDropCurrentShape = System.nanoTime();
  }
  
  /**
   * @return -1 if no drop has occurred, or the number of milliseconds since 
   *     the last drop.
   */
  public synchronized long getLastDropCurrentShapeMilliSec() {
    if (lastDropCurrentShape == -1) {
      return -1;
    } else {
      return (System.nanoTime() - lastDropCurrentShape) / 1000000;
    }
  }
  
  /**
   * Background Color that will be laid down first
   */
  @Override
  protected int getBackgroundColor() {
    return Color.TRANSPARENT;
  }
  
  /**
   * Paint used to draw the crosshatches between cells.
   * @return null to disable, or the Paint to use.
   */
  @Override
  protected Paint getInterCellCrossHatchPaint() {
    Paint crossHatchPaint = new Paint();
    crossHatchPaint.setColor(Color.GRAY);
    crossHatchPaint.setStyle(Style.STROKE);
    return crossHatchPaint;
  }
  
  /**
   * Paint used to draw the border
   * @return null to disable or the Paint to use.
   */
  @Override
  protected Paint getBorderPaint() {
    Paint borderPaint = new Paint();
    borderPaint.setColor(Color.WHITE);
    borderPaint.setStyle(Style.STROKE);        
    return borderPaint;
  }
   
  public synchronized void restoreState(SharedPreferences sharedPreferences) {
    reset();
    
    // Convert our strings to serialized versions (required, as the preferences
    // API can't handle serialized versions). 
    field = Base64.decodeFromString(sharedPreferences.getString(FIELD_KEY, ""));
    
    if (sharedPreferences.contains(CURRENT_SHAPE_KEY)) {
      currentShape = Base64.decodeFromString(
          sharedPreferences.getString(CURRENT_SHAPE_KEY, ""));
      currentShapeX = sharedPreferences.getInt(CURRENT_SHAPE_X_KEY, 0);
      currentShapeY = sharedPreferences.getInt(CURRENT_SHAPE_Y_KEY, 0);
    }
      
    heightCells = sharedPreferences.getInt(HEIGHT_CELLS_KEY, 0);
    widthCells = sharedPreferences.getInt(WIDTH_CELLS_KEY, 0);
    Log.i(LOG_TAG, "Loaded field information");    
  }

  public synchronized void saveState(Editor editor) {
    // Convert our serialized view to strings (required for the preferences 
    // API, which can't handle serializable directly).
    
    // Delete the current shape from the field, then re-add it. This way object
    // identity doesn't mess us up later when we restore.

    if (currentShape != null) {
      editor.putString(CURRENT_SHAPE_KEY, 
          Base64.encodeObject(currentShape, Base64.DONT_BREAK_LINES | Base64.GZIP));
      editor.putInt(CURRENT_SHAPE_X_KEY, currentShapeX);
      editor.putInt(CURRENT_SHAPE_Y_KEY, currentShapeY);
      deleteShape(currentShape);
    } else {
      editor.remove(CURRENT_SHAPE_KEY);
      editor.remove(CURRENT_SHAPE_X_KEY);
      editor.remove(CURRENT_SHAPE_Y_KEY);
    }
    
    try {  
      editor.putString(FIELD_KEY, 
          Base64.encodeObject(field, Base64.DONT_BREAK_LINES | Base64.GZIP));
    } finally {
      if (currentShape != null) {
        addShape(currentShape, currentShapeX, currentShapeY);
      }
    }
    
    editor.putInt(HEIGHT_CELLS_KEY, heightCells);
    editor.putInt(WIDTH_CELLS_KEY, widthCells);
        
    Log.i(LOG_TAG, "Persisted current field information"); 
  }
}
